sttpのSttpBackendStubを試してみた
はじめに
ScalaのHTTPクライアントはHammokを使っていたのですが、scala 1.13 に対応していないということで代わりになるものを探していました。Hammokと同じようにバックエンドが選択可能で複数のEffectコンテナと組み合わせて使えるsttpがよさそうだったので試していたら、クライアントのスタブ化ができるのを見つけたので試してみました。
sttpとは
GitHubのREADMEでは以下のように説明されています。
ざっとみただけでもかなり多くの機能があったりカスタマイズができそうで、まさに俺たちの欲しかったクライアントって感じを受けました。
The Scala HTTP client that you always wanted! sttp client is an open-source library which provides a clean, programmer-friendly API to describe HTTP requests and how to handle responses. Requests are sent using one of the backends, which wrap other Scala or Java HTTP client implementations. The backends can integrate with a variety of Scala stacks, providing both synchronous and asynchronous, procedural and functional interfaces.
バージョンなど
今回使ったbuild.sbtは以下の通りです(抜粋)
スタブに関していえばバックエンドはどれを使っても同じです。
scalaVersion := "2.13.3" libraryDependencies += "com.softwaremill.sttp.client3" %% "core" % "3.0.0-RC9" libraryDependencies += "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % "3.0.0-RC9"
スタブ
スタブについてはドキュメントのTestingのページに記載があります。
まずスタブは以下のように定義されています(抜粋)。
class SttpBackendStub[F[_], +P]( monad: MonadError[F], matchers: PartialFunction[Request[_, _], F[Response[_]]], fallback: Option[SttpBackend[F, P]] ) extends SttpBackend[F, P] { def whenRequestMatches(p: Request[_, _] => Boolean): WhenRequest = new WhenRequest(p) def whenAnyRequest: WhenRequest = whenRequestMatches(_ => true) def whenRequestMatchesPartial( partial: PartialFunction[Request[_, _], Response[_]] ): SttpBackendStub[F, P] = { val wrappedPartial: PartialFunction[Request[_, _], F[Response[_]]] = partial.andThen((r: Response[_]) => monad.unit(r)) new SttpBackendStub[F, P](monad, matchers.orElse(wrappedPartial), fallback) }
インスタンス化する時には各バックエンドの#stub
メソッドで生成します。例えば以下のようになります。
import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend implicit val backend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend.stub[IO]
セットアップ
リクエストとレスポンスのセットアップをします。以下のようにリクエストに対する述語とそれに対応する応答を指定していきます。セットアップに使うメソッドは新たなスタブを返すのでメソッドチェーンで記述する必要があります。これに気づかずに少しはまりました。
//スタブのセットアップ implicit val backend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend.stub[IO] //annは見つかる .whenRequestMatches(req => req.uri.params.get("name").fold(false)(_==="ann")).thenRespond("Ann") //それ以外は404 .whenAnyRequest.thenRespondNotFound()
実際に使ってみる
いつもAPIクライアントを作っているのと同じ要領でHTTPクライアントとして使用APIクライアントの実装に使用して、implicit パラメータでテスト時にスタブに差し替えるのをイメージした例を書いてみました。
package example import cats.data.OptionT import cats.effect.{Effect, ExitCode, IO, IOApp} import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend import sttp.client3.{SttpBackend, basicRequest} import sttp.client3.testing.SttpBackendStub import sttp.model.Uri import cats.implicits._ import io.circe.syntax._ import io.circe.generic.auto._ //ウロコインコリソース final case class Conure(name:String) //APIクライアント trait APIClient[F[_]] { def findBirdy(name:String): OptionT[F, Conure] } object APIClient { //implicit パラメータでバックエンドを差し替える //テスト時にはスタブを指定する implicit def onHttpClient[F[_]: Effect](implicit backend:SttpBackend[F, Any]):APIClient[F] = (name: String) => OptionT { basicRequest .get(Uri.unsafeParse(s"http://localhost/birdy/_by-name").withParam("name", name)) .send(backend) .flatMap { res => res.code.code match { case 404 => Effect[F].pure(none) case _ => res.body .leftMap((s: String) => new RuntimeException(s)) .flatMap(io.circe.parser.parse(_).flatMap(_.as[Conure])) .fold(Effect[F].raiseError[Option[Conure]], b => Effect[F].pure(b.some)) } } } } object StubTest extends IOApp { //テスト override def run(args: List[String]): IO[ExitCode] = { import APIClient._ //スタブのセットアップ implicit val backend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend.stub[IO] //annは見つかる .whenRequestMatches(req => req.uri.params.get("name").fold(false)(_==="ann")).thenRespond(Conure("ann").asJson.noSpaces) //それ以外は404 .whenAnyRequest.thenRespondNotFound() val client:APIClient[IO] = implicitly[APIClient[IO]] for { //annは見つかる _ <- client.findBirdy("ann").getOrElseF(IO.raiseError(new AssertionError("ann should be found."))) //それ以外は404 _ <- client.findBirdy("piyopiyo").foldF(IO.unit)(_ => IO.raiseError(new AssertionError("piyopiyo should not be found"))) } yield ExitCode.Success } }
まとめ
sttpのスタブを試してみました、HTTPクライアントのスタブを作る時には汎用的なスタブライブラリだと指定することが多くなりがちなのでクライアントのAPIレベルでスタブ機能が揃っているのは記述量が少なく済むので便利そうだと思います。